Skip to content

feat: out-of-process host for existing Python CPEX plugins - #149

Open
tedhabeck wants to merge 5 commits into
devfrom
feat/python_plugin_compat_2
Open

feat: out-of-process host for existing Python CPEX plugins#149
tedhabeck wants to merge 5 commits into
devfrom
feat/python_plugin_compat_2

Conversation

@tedhabeck

@tedhabeck tedhabeck commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes: #20

Adds cpex-hosts-python, the crate that lets the Rust PluginManager run the
Python plugins that already exist — PII filters, identity resolvers, token
delegators — without rewriting them. It builds a per-plugin virtualenv, launches
the framework's worker.py in it as a long-lived subprocess, and speaks the same
newline-delimited-JSON stdio protocol the Python CLI already uses.

The point is migration continuity: a gateway can move to the Rust manager while
its existing Python plugins keep working unchanged.

Why

The Rust PluginManager cannot run a Python plugin on its own. This crate is the
Rust counterpart to the Python CLI's client.py + venv_comm.py. Rather than
port those as a one-to-one translation, it splits the three concerns — venv
build/cache, worker subprocess/protocol, and per-hook serialization — so each
tests in isolation and shared-package venv handling falls naturally to the venv
manager.

  PluginManager
    └── isolated_venv factory ──> IsolatedPythonPlugin ──┬── venv manager
                                   (initialize/shutdown) └── worker client
          └── hook adapter (serialize, send, deserialize)     │
                                                              ▼
                                        worker.py subprocess in the venv

Venv construction and worker launch happen in initialize() (rollback on
failure), teardown in shutdown(). Neither belongs on the invoke path — a cold
pip install is measured in minutes.

What's here

Venv lifecycle (venv.rs) — build, cache, and resolve per-plugin venvs,
including shared-package layouts. Cache metadata is keyed per host class.

Worker protocol (worker.rs) — subprocess supervision and the NDJSON stdio
framing, with the max_content_size frame bound enforced on both sides.

Hook dispatch (plugin.rs, conversion.rs, legacy/) — one adapter per
declared hook, carrying the native Pydantic payload shapes (ToolPreInvokePayload
and friends) rather than a generic wrapper, so existing plugins validate as-is.

Credentials (credentials.rs) — the framework strips raw tokens at every
process boundary (token fields are #[serde(skip)]), but identity and delegation
plugins genuinely need them. This adds a capability-gated wire DTO: a plugin
declaring read_inbound_credentials or read_delegated_tokens gets a dedicated
credential object built by reading the in-memory token directly. Production
credential types keep their serde guard and are never serialized. Fail-closed
rules and the residual exposure this does not close are documented in the
module.

Extensions delivery (extensions.rs) — the executor's capability-filtered
Extensions is serialized onto the task, so a 3-arg (payload, context, extensions) hook sees out-of-process what it would see in-process. Returns come
back through the executor's existing copy-on-write merge, which enforces the
mutability tiers; this host adds no tier logic of its own. Sensitive headers
(Authorization, Cookie, X-API-Key) are stripped in both directions,
case-insensitively, and raw_credentials never rides this channel.

Tests

172 passing (152 unit + 20 integration), 2 #[ignore]d by default because they
need an installed plugin with a built venv.

Suite Covers
isolated_venv_e2e.rs Adapter called directly against a scaffolded fixture
credential_e2e.rs The capability-gated plaintext credential channel
extensions_merge_e2e.rs Inbound wire shape; every mutability tier through the real executor
config_e2e.rs YAML → factory → registry → invoke_by_name → executor → worker, against a plugin installed the way an operator installs one

extensions_merge_e2e.rs is worth a note on test design. The accept-side tier
outcomes can't be unit tested: http, security, and delegation writes are
gated by a WriteToken whose constructor is pub(crate) to cpex-core, so this
crate cannot mint one even in a test — which is exactly the property that makes
the gate trustworthy. Those paths are therefore driven through a real pipeline,
with tokens minted by the executor from declared capabilities. A fake_worker
stand-in covers the same merges without a Python subprocess by calling the
production parser on a canned response, so the code under test is identical and
only the subprocess is replaced.

Cross-surface verification, and one finding

The host and the Python worker land on different branches and ship separately, so
docs/specs/extensions-wire-contract.md pins the contract they share — neither
side's source can serve as the other's reference.

This branch ran the two against each other for the first time, via populated
Extensions on the tool_pre_invoke hook in config_e2e.rs. The channel works:
extensions cross into a real worker subprocess, are reconstructed there, and the
hook runs. It also overturned two claims the spec had recorded about the known
http-slot divergence, in the reassuring direction.

The divergence itself is unchanged and known — Rust HttpExtension has
request_headers/response_headers, Python has a single headers. The spec said
a Rust-shaped slot fails validation in the Python model and is dropped with a
warning. Both claims are wrong. Neither model sets extra="forbid" /
deny_unknown_fields, and both default their header maps, so unknown keys are
silently ignored and missing ones default to empty:

Direction On the wire Deserializes to Warning
Rust → Python {"request_headers": {"X-Request-Id": "r1"}} HttpExtension(headers={}) none
Python → Rust {"headers": {"X-Fine": "yes"}} request_headers: {}, response_headers: {} none

Verified empirically against both models, not inferred. The slot arrives
present-but-empty — the "hollow slot" this same contract explicitly rejects for
raw_credentials. A plugin reads extensions.http.headers, gets {}, and cannot
distinguish "no headers on this request" from "the headers didn't cross."

Two follow-on corrections went into the spec:

  • reconstruct_extensions calls model_validate on the whole object, so a
    genuinely-failing slot would zero out all extensions, not degrade per-slot as
    the doc implied.
  • PipelineResult::modified_extensions is the pipeline's final view and is
    always Some(...) on an allowed pipeline — not a "something changed" flag.
    Its own doc comment in executor.rs says otherwise; noted in the spec, comment
    left alone as out of scope here.

This is a correctness bug, not a security one. The sensitive-header strip runs
pre-serialization against the fields the sending model actually declares, so
Authorization never reaches the wire regardless of which shape the receiver
expects. config_e2e.rs asserts exactly that: no credential header value appears
anywhere in the serialized task JSON.

Known gaps

Tracked as Remaining work in the spec:

  1. The http slot needs a mapping on one side. The other eleven slots cross
    correctly.
  2. No test asserts a header value survives the round trip. This is the gap
    that let the divergence stay misdescribed: existing tests assert either on the
    wire JSON (Rust-shaped, so it passes) or on the hook running (it does). Write
    it as a failing test, then fix (1).
  3. The extension models' permissive defaults are what turned a loud shape
    mismatch into a silent one. Tightening trades the deliberate slot-level
    version-skew tolerance, so it's a real design call rather than an obvious win.
  4. The cross-surface run is not yet CI-reproducible, and this is the most
    important caveat on the verification above.

On (4), specifically

The worker side is unreleased (#113). Reproducing the run needs the plugin installed
and its venv's cpex replaced by hand:

cpex plugin --type test-pypi install "cpex-test-plugin@>=0.2.0"
plugins/<slug>/.venv/bin/pip install \
  "git+https://github.com/contextforge-org/cpex.git@feat/python_plugin_compat_0.1.x"
cargo test -p cpex-hosts-python --test config_e2e -- --ignored --nocapture

The version numbers collide. The branch build self-reports 0.1.2, and PyPI
publishes a cpex 0.1.2 that is a different artifact with no extensions support
(no EXTENSIONS_FIELD, no reconstruct_extensions). Nothing in pip list or the
dist-info version distinguishes them, so a venv can look correctly provisioned and
silently have no extensions channel. Check direct_url.json, not the version.

The consequence for reviewers: config_e2e.rs currently guards only on the venv
existing, so a registry-provisioned venv yields a green test that proves
nothing
— the worker ignores the unknown extensions field, the hook still runs,
the marker still lands. The guard should grep the installed worker.py for
EXTENSIONS_FIELD, as testing::worker_delivers_extensions already does for the
CPEX_PYTHON_SOURCE path. Until then, treat a green config_e2e.rs as
conditional on having checked the venv's provenance.

Before merging

The working tree carries untracked artifacts that should be sorted out — none are
gitignored:

  • docs/specs/extensions-wire-contract.md and the docs/plans/ +
    docs/brainstorms/ files — these are worth committing; the spec is
    referenced throughout this description.

Checks

  • make lint passes
  • make test passes
  • CHANGELOG updated (if user-facing)

Signed-off-by: habeck <habeck@us.ibm.com>
Signed-off-by: habeck <habeck@us.ibm.com>
Signed-off-by: habeck <habeck@us.ibm.com>
Signed-off-by: habeck <habeck@us.ibm.com>
Signed-off-by: habeck <habeck@us.ibm.com>
@tedhabeck
tedhabeck marked this pull request as ready for review August 1, 2026 01:30
@araujof araujof changed the title [FEATURE]: out-of-process host for existing Python CPEX plugins feat: out-of-process host for existing Python CPEX plugins Aug 3, 2026
@araujof
araujof changed the base branch from main to dev August 3, 2026 00:34

@araujof araujof left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work!

Potential blockers:

The extensions merge writes a plugin's filtered view back over canonical state. extensions.rs:265 starts from inbound.cow_copy(), which is the capability-filtered view, and the custom slot is accepted unconditionally and sets applied on its own. merge_owned then swaps whole slots (cpex-core container.rs:238). Meanwhile the executor's only content check is the label superset, gated on read_labels, with a bare else that merges anyway (executor.rs:463). So a plugin with no security capabilities can wipe the pipeline's security labels just by returning a custom value, and with a labels token it can rewrite subject and auth_method too. Delegation isn't validated at all. The description says the executor enforces the mutability tiers; it does, but per slot rather than per field, and that's the gap. Merging field by field from the inbound value on the three gated slots should close it.

Nothing checks that the worker understands what we're sending. plugin.rs:433 spawns and returns. An older worker.py silently ignores the credential field, so a plugin that asked for credentials runs with none, which is what fail-closed rule 2 exists to prevent. Given the 0.1.2 version collision you documented, that's reachable in production, not just in tests. The grep in testing.rs would do for now; a capabilities task type would be better.

Related, and probably why both of those survived this long: the test suite reports ok while skipping. All four credential_e2e tests skip and pass, including the negative ones, and 10 of 20 integration tests do the same. Nothing runs --ignored in CI. I only caught it by running with --nocapture. I'd fix that first, since right now it reports safety that isn't there.

Other findings:

  • Set-Cookie isn't in SENSITIVE_HEADERS although the comment says it is (extensions.rs:91), and both strip tests assert with is_sensitive, so they can't fail.
  • The returned http slot blanks method, path, host and scheme (:297). Policies gate on those per CHANGELOG 0.2.2, so it's a bit worse than the correctness bug you flagged.
  • max_content_size is only checked outbound (worker.rs:251). reader_loop and stderr_loop are unbounded.
  • The delegated token pick filters on mode but not audience (credentials.rs:324), so map order decides which token a plugin gets.
  • A dead worker is never replaced (plugin.rs:328), and there's no timeout on pip install (venv.rs:445), which stalls startup since init is sequential.
  • Two plugins sharing a package will delete each other's venv (venv.rs:423).
  • extensions-wire-contract.md doesn't exist anywhere, on disk or in any commit. It's cited throughout the description as the reference, so I had nothing to check the contract against. The real material is in cmf-message-spec.md section 3.

The raw-credentials reversal I'd rather have a decision on than a fix. cpex-core raw_credentials.rs:37 says handlers needing raw material must run in-process, and this deliberately undoes that. Your write-up is honest about the residual exposure, so it's really whether we accept it. Either way the two crates shouldn't be left asserting opposite invariants.

Smaller: zeroize is declared and never used, error.rs:126 throws away the pip exit code and stderr into an empty details map, the crate isn't actually in default-members despite its comment, CHANGELOG wasn't updated, and gitignore's plugins/ is unanchored so it swallows builtins/plugins/.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE]: Python plugin backward compatibility with existing framework

2 participants